余弦、内积与欧氏距离:RAG 该用哪个度量
余弦看方向,欧氏看位置,内积两个都看。RAG 比较的是「两段文字是不是一个意思」,与文本长短无关,所以要的是方向,选余弦。 而一旦向量做过 L2 归一化,三者的排序结果就完全一致了——所以真正该做的动作不是纠结选哪个度量…
余弦、内积与欧氏距离:RAG 该用哪个度量
修订说明(核实于 2026-08-13,东八区) 本篇的数学部分经复核全部正确(枢纽等式、 的推导、几何示例的数值),修订集中在工程结论:
- 撤回「归一化后改用 IP 更快」这一核心建议。Milvus 官方文档从未做过此性能比较;模长在建索引时已算好,两者查询开销基本相同。归一化的收益是确定性,不是速度。
- 补上「两侧都要归一化」:原文只强调入库侧,漏了查询侧。
- 阈值换算漏了平方:Milvus / FAISS 的 L2 返回的是平方欧氏距离, 对应库内分数 0.5 而非 0.707。
- 「高维 L2 失去区分度而余弦不受影响」与本文自己的单位球等价结论矛盾,已限定为「仅对未归一化向量成立」。
- 删除三条错误的 L2 适用场景:人脸/声纹实际用余弦(ArcFace 等在超球面训练)、鞋码应走标量过滤而非 Embedding 几何、chunk 去重用余弦同样可行。
- 「文本越长模长越大」是示例的人为设定,不是模型性质,已标注。
- TopK 50/50→20→5 改为「实验起点」而非生产配置。
新增前置步骤:一切度量讨论的前提是先读你所用模型的 model card,按它规定的归一化和 query/document 编码协议来。
阅读提示 本笔记的前置阅读是 RAG概念(为什么 RAG 需要向量检索)。本篇只回答一个问题:建 Collection 时
metric_type该填 COSINE、IP 还是 L2,以及为什么这个选择其实没你想的那么重要。默认读者已经知道什么是 Embedding、什么是 TopK 召回,正在配置一个真实的向量库。
本篇不讲 BM25 怎么算分(见 BM25 算法原理),不讲两路召回放在哪个存储(见 混合检索选型)。本篇只管向量这一路,用什么公式比较两个向量。
⚠️ = 常见陷阱 🆚 = 对比说明 💡 = 机制或选择建议
目录
核心概念 余弦看方向,欧氏看位置,内积两个都看。RAG 比较的是「两段文字是不是一个意思」,与文本长短无关,所以要的是方向,选余弦。
而一旦向量做过 L2 归一化,三者的排序结果就完全一致了——所以真正该做的动作不是纠结选哪个度量,是入库时把向量归一化。
一、两个度量分别在算什么?
先把两个公式摆在一起。设两个 n 维向量 、:
余弦相似度,看两者的夹角:
欧氏距离,看两点之间的直线距离,就是勾股定理推广到 n 维:
同一组向量,两个度量给出的答案不一样
拿一组最简单的数据跑一遍。取 ,——注意 就是 乘以 2,两者方向完全相同:
余弦:
点积 = 1×2 + 2×4 = 10
‖A‖ = √(1+4) = √5 ≈ 2.236
‖B‖ = √(4+16) = √20 ≈ 4.472
cos = 10 / (2.236 × 4.472) = 10 / 10 = 1.000 ← 完全同向
欧氏:
L2 = √((1-2)² + (2-4)²) = √(1+4) = √5 ≈ 2.236 ← 明显不为零余弦说「这俩一模一样」,欧氏说「这俩差了 2.236」。两个都没算错,它们回答的本来就是两个问题:
| 维度 | 余弦相似度 | 欧氏距离 |
|---|---|---|
| 衡量什么 | 方向(夹角) | 空间中的直线距离 |
| 取值范围 | ||
| 好的方向 | 越大越相似 | 越小越相似 |
| 对模长 | 不敏感,公式里已除掉 | 非常敏感 |
注意「越大越好」和「越小越好」是反的 这一点看着琐碎,但它是后面 陷阱 4:把分数当阈值直接照搬 的根源。余弦是相似度,欧氏是距离,一个升序一个降序,任何写死的阈值换个度量就全反了。
工程上还有个常见变体:欧氏距离平方,省掉开根号这一步。
开不开根号不影响排序,所以 FAISS 的 L2 默认算的就是平方值,不要拿它去和别处的距离数值直接比大小。
二、内积和余弦是什么关系?
一句话:内积 = 余弦 × 两个向量的模长。
把余弦公式两边乘开就得到了这条全篇的枢纽等式:
所以内积既看夹角,也看长度:向量越长,内积越大。余弦则把长度这一项彻底除掉了。
未归一化时,内积会被长度带偏
设查询 ,三个候选 chunk:
| 向量 | 含义 | 与 q 的关系 |
|---|---|---|
| 同义、写得短 | 方向完全相同 | |
| 同义、写得啰嗦 | 方向完全相同 | |
| 跑题,但篇幅最长 | 有 16.9° 夹角 |
图分两半读:左边是几何, / / 三个箭头压在同一条射线上(方向一致,只是长短不同), 明显偏离;右边是三种度量分别打出的分。
C1(短同义) C2(长同义) C3(跑题但最长)
余弦 1.000 1.000 0.957 ← C1 = C2 并列第一 ✅
内积 2.40 8.00 9.20 ← C3 排第一 ❌
欧氏 0.80 2.00 2.95 ← C2 被判成很远 ❌和 是同一句话的长短两个版本,RAG 想要的答案是「它们同样相关」。只有余弦给出了这个答案。内积把模长最大的跑题 chunk 顶到了第一,欧氏则狠狠惩罚了模长偏大但方向正确的 。
⚠️ 这个例子演示的是「模长差异」,不要读成「文本越长模长越大」 上表把 标成「写得啰嗦」、模长设成 4,容易让人得出「文本越长 → Embedding 模长越大」的印象。这一步是人为设定的,不是模型的性质。
实际上多数句向量模型(
bge、e5等)在池化后会做归一化或近似归一化输出,文本长度和最终模长之间没有可靠的单调关系——长文本的 token 向量平均下来甚至可能互相抵消,得到更小的模长。这个例子的正确读法是:假设两个向量方向相同但模长不同,三种度量会怎么排。 至于「模长差异从哪来」,那是另一个问题——可能来自模型没归一化、来自不同模型混用、来自你自己做了加权求和。先确认你的向量确实存在模长差异(
np.linalg.norm()看一眼),再决定要不要在意这件事。
归一化之后,三者变成同一件事
如果入库时把每个向量都除以自己的模长,即 ,那么 ,代入枢纽等式:
内积和余弦数值完全相等。 欧氏距离也被绑定了:
用上面的 验一下():
按公式推: √(2 - 2×0.9567) = √0.0866 = 0.2943
直接算: q̂ = [1, 0]
Ĉ3 = [4.6/4.8083, 1.4/4.8083] = [0.9567, 0.2911]
L2 = √((1-0.9567)² + 0.2911²) = √0.08661 = 0.2943 ← 一致是关于 的单调递减函数,所以余弦越大 ⇔ 欧氏越小,两个排序严格互为镜像,TopK 结果完全相同。
⚠️ 「归一化后改用 IP 会更快」——这个说法站不住 本篇早前版本写的是「内积只需一轮乘加,余弦还要额外算两个模长,所以 IP 更快」,并说这是 Milvus / FAISS 文档的意思。核对官方文档后,这个说法要撤回。
① 官方没有做过这个性能承诺。 Milvus 的度量文档只说明了三种度量各自的定义和适用场景,通篇没有比较过它们的速度。「IP 明显更快」是社区流传的推断,不是文档结论。
② 「每次查询重算模长」不是数据库的实际实现。 向量库不会在每次比较候选时现算两个模长——文档向量的模长在建索引时就已算好并存下(Milvus 对 COSINE 的做法是入库时归一化,此后按内积执行),查询向量的模长每次查询只算一次,摊到成千上万个候选上可以忽略。也就是说,归一化数据上 COSINE 和 IP 的查询开销基本相同。
③ 真正的开销大头在别处。 ANN 检索的时间花在图遍历(HNSW)或聚类探查(IVF)上,距离计算本身只占一小部分。想快,该调的是
ef/nprobe/ 量化方式,不是度量名。那还要不要归一化?要,但理由是「正确」不是「快」: 归一化消除了模长这个噪音变量,让 IP 和 COSINE 的语义合一,从此不必再担心「选错度量会不会改变排序」。这是确定性收益,不是性能收益。 度量选
COSINE还是IP在归一化数据上几乎无差别——真要挑,选COSINE更稳妥:即使某天有人漏了归一化,它也会自己补上,而 IP 会静默给出被模长带偏的结果。
⚠️ 用 IP 实现 cosine 时,两侧都要归一化 常见的漏做法是只归一化入库向量,忘了查询向量。
——只要有任何一侧没归一化,结果就仍然带着那一侧的模长。查询侧没归一化时后果反而不明显:同一次查询里所有候选都乘了同一个 ,排序不变,只是分数整体缩放——于是你的绝对阈值失效了,而 TopK 看起来一切正常。这种「只坏一半」的故障最难查。
所以:入库侧和查询侧用同一个归一化函数,写在同一个封装里,别两处各写一遍。
三、为什么 RAG 默认选余弦?
RAG 的召回在问:用户这句话和这个 chunk,是不是在聊同一件事。 这是个纯方向问题。有三条理由把欧氏距离排除掉。
第一,chunk 长度是噪音,不是信号。
同一个问题「什么是大模型幻觉」,知识库里可能有两段答案:一段 50 字直接给定义,一段 500 字绕了半天但也说对了。两者向量方向几乎一致,模长却差很多。余弦认为它俩同样相关,这是对的;欧氏会把长的那段推远,等于对写得详细的文档施加惩罚。RAG 里 chunk 长短天然不齐,这个惩罚纯属干扰。
第二,主流文本 Embedding 的模长通常不承载你想要的语义。
BGE、E5、GTE、OpenAI 的 text-embedding-* 这类模型,训练时普遍采用对比学习,loss 围绕余弦相似度或(温度缩放的)内积设计——优化目标主要约束方向。
⚠️ 但别把这条推成「模长一定没有任何信息」。各家模型的 loss 细节并不相同(有的做了显式归一化、有的没有),也有研究发现模长与词频、文本长度等因素存在相关性。准确的说法是:模长不是模型有意用来表达「这段文本更重要 / 更可信」的通道,所以把它带进相似度计算,等于引入一个你无法解释、模型也没打算表达的变量。
判断方法比记结论可靠: 拿你实际用的模型编码一批文本,np.linalg.norm() 看看模长分布。如果分布很窄(说明模型自己就在输出近似单位向量),归不归一化差别不大;如果分布很宽,那这个变量正在悄悄影响你的 IP 排序。
第三,未归一化的高维欧氏距离容易失去区分度。
RAG 的向量是 768、1024、3072 维的。维度越高,任意两点的欧氏距离越趋于集中在一个相近的值附近,「最近」和「较近」拉不开差距,这就是维度灾难在距离度量上的表现。
⚠️ 这条只对未归一化的向量成立,别和上一节的结论打架 上一节刚证明过:向量归一化后 ,两者严格单调等价,排序完全相同。既然排序相同,就不可能出现「L2 失去区分度而余弦没有」——同一批向量上,两者的区分度是同一件事。
所以第三条理由要限定清楚:它说的是没做归一化时,模长差异叠加高维集中效应,让 L2 的分数挤成一团。一旦归一化,这条理由自动消失。
换句话说,三条理由指向的都是同一个动作——见下方总结。
三条理由的共同点 它们说的都是同一件事:欧氏距离携带了模长信息,而在 RAG 里模长通常是噪音。 所以正确的处理不是「避开欧氏」,是「先把模长信息去掉」——也就是归一化。去掉之后,三种度量给出的 TopK 完全相同,选哪个都对。
四、欧氏距离该用在哪里?
🆚 这里要说清楚:欧氏距离不是差的度量,只是和 RAG 的目标不匹配。只要你关心绝对数值差多少,而不是方向对不对,就该用欧氏。
| 场景 | 为什么用欧氏 | 注意 |
|---|---|---|
| K-means 等聚类 | 判断样本归属哪个簇,看的是它在空间里的绝对位置离哪个中心近 | 这是 K-means 的定义本身要求的(它最小化簇内平方和)。要按方向聚类得用球面 K-means |
| 风控与异常检测 | 正常行为聚成一团,离群点的判据就是「离中心的物理距离超过阈值」 | 前提是各维度已做过量纲对齐,否则量纲大的维度会主导距离 |
| 真正的数值特征 | 传感器读数、坐标、价格这类本身就是数值的特征向量 | 见下方警告:这里指的不是 Embedding |
⚠️ 原表里有三行是错的,已删除,说明如下 ① 「人脸 / 声纹比对适合 L2,因为模长编码特征强度」——反了。 当代主流人脸识别(ArcFace、CosFace 及其衍生)恰恰是在超球面上做的:训练时就把特征和权重都归一化,损失直接作用于夹角,推理时用余弦相似度比对。模长在这些系统里被有意消除,正因为它编码的是图像质量、光照、姿态这类与身份无关的因素。声纹(如 ECAPA-TDNN)同样以余弦打分为主。
② 「搜『25cm 的鞋』用 Embedding 的 L2,24.5cm 就会排在 27cm 前面」——不成立。 Embedding 空间里不存在「1 厘米对应多少距离」这种线性几何关系。模型对数字的表示由分词方式和训练语料决定,
24.5和27可能被切成完全不同的 token,几何上谁近谁远无法预期。正确做法是根本不要用向量检索解决这个问题:把尺码存成结构化数值字段,用范围过滤或排序:
client.search(..., filter="size_cm >= 24 and size_cm <= 26")这是标量过滤该干的活(见 Milvus 实操与五种检索)。凡是能写成
WHERE的条件,就不该指望 Embedding 的几何来表达。③ 「chunk 去重适合 L2」——L2 不是必需的。 去重判断的是「两段文本是不是几乎一样」,这在归一化向量上用余弦同样成立(且阈值更好定,比如
> 0.95)。而且归一化后两者排序等价,谈不上谁更适合。真正影响去重质量的是切块粒度和阈值标定,不是度量选择。
一句话切分:
比较的是「意思像不像」→ 余弦 / 内积;比较的是「本身就是数值的量差多少」→ 欧氏距离。
关键限定:后半句里的「数值」指真正的数值特征(坐标、读数、价格),不是把数值写进文本再过 Embedding 得到的向量。
五、常见陷阱
陷阱 1:向量没归一化就直接用 IP
最容易踩的一个,因为它不报错,只是悄悄让召回质量变差。
⚠️ 未归一化直接用内积 错误操作: 建索引时选了
metric_type="IP",但入库的向量是模型原样输出、没做 L2 归一化。实际结果: 长 chunk、重复词多的 chunk 稳定占据 TopK 前排;明明更精准的短答案被挤到后面。看起来像「召回不准」,但换 Embedding 模型、调切块大小都不见好转。
原因: ,模长直接乘在分数上。一个方向偏一点但足够长的向量,内积可以轻松超过方向完全正确的短向量(见 二、内积和余弦是什么关系? 里 压过 的例子)。
正确做法: 二选一——入库前执行
v = v / np.linalg.norm(v),或者干脆把metric_type换成COSINE(由库来做归一化,代价是每次查询多算模长)。推荐前者,一次性成本换全程性能。
陷阱 2:反复切换 metric_type 想提升召回质量
这个陷阱浪费的时间最多。
⚠️ 把度量当成调优旋钮 错误操作: 召回效果不好,于是把
COSINE改成IP,再改成L2,反复重建索引对比效果。实际结果: 三次跑出来的 TopK 几乎一模一样,评测指标纹丝不动,几小时就这么没了。
原因: 向量已归一化的前提下,、,三个度量对同一批向量给出的排序完全相同,只是分数刻度不同。换度量在数学上不可能改变 TopK。
正确做法: 承认度量这一环没有调优空间,把精力放到真正能动的地方——切块策略、Embedding 模型、加一路 BM25(见 混合检索选型)、上 reranker。这些才会改变排序。
陷阱 3:建完索引才想改度量
⚠️ metric_type 是索引的一部分,不能事后改 错误操作: Collection 建索引时用了
L2,上线后想换成COSINE,直接在查询参数里改。实际结果: 要么直接报 metric type 不匹配的错误,要么(更糟)在某些客户端版本里静默按索引的度量执行,你以为换了其实没换,评测结论完全建立在错误前提上。
原因: 距离度量是在建索引时烧进索引结构的(HNSW 的邻居图、IVF 的聚类中心都按该度量算好了),查询参数只能与之一致,不能覆盖。
正确做法: 建库前就定好,且建索引与查询用同一个度量。要改就得 drop index 重建。既然改了排序也不变(见陷阱 2),通常最优解是直接归一化 + 用 IP,一次定死。
陷阱 4:把分数当阈值直接照搬
⚠️ 相似度阈值跨度量搬运 错误操作: 在余弦下调好了「score > 0.75 才算命中」,换到 L2 索引后沿用 0.75 这个数。
实际结果: 过滤逻辑彻底反向——该丢的全留下,该留的全丢掉。
原因: 余弦是相似度,越大越像,取值 ;欧氏是距离,越小越像,取值 。方向相反,量纲也不同。
正确做法: 阈值必须和度量绑定记录,换度量就重新标定。更稳的做法是别用绝对阈值,改用 TopK + reranker 分数来卡——绝对阈值在换 Embedding 模型后同样会失效。
⚠️ 换算阈值时的第二个坑:Milvus / FAISS 返回的是平方** L2** 这一步几乎所有人都会算错,包括本篇早前的版本。
数学上,归一化向量满足 ,所以 对应 。
但数据库返回的不是这个数。 Milvus 官方文档明确写着:选择欧氏距离时,只计算开平方之前的值。FAISS 的
IndexFlatL2同理,返回的也是 squared L2。所以:
对应的值 数学上的 L2 距离 Milvus / FAISS 实际返回的 distance把 0.707 当阈值填进 Milvus,你实际卡的是 ,门槛比你以为的松了一截——而且不报错,只是悄悄多放进来一批不够相关的文档。
怎么避免: 换算公式直接用 (不开根号),或者更省事——先跑几条已知相似度的样本,看数据库实际返回什么数,用实测值反推,别用纸上公式。这条规则在换向量库时要重新确认一次:不是所有库都返回平方值。
六、生产级 RAG 配置
把上面的结论收成一条可以直接照做的链路。
💡 三步定型 0|先读你所用 Embedding 模型的 model card。 这是第一步,不是补充说明。模型方会明确给出推荐度量、是否已内置归一化、query 和 document 要不要加不同前缀(
bge系列的"为这个句子生成表示以用于检索文档:"、e5的query:/passage:)。这些协议错了,后面所有度量讨论都没有意义——本篇的所有等价关系只在「双方都已归一化」这个前提下成立。1|入库和查询两侧都归一化。
embedding = embedding / norm(embedding),封装成一个函数,两侧共用。做完这一步,COSINE / IP / L2 搜出来的 TopK 一致,不必再纠结度量选哪个(推荐仍填COSINE,它对漏归一化有兜底)。2|召回走两路。 向量召回和 BM25 召回各取 TopK,用 RRF 融合。TopK 具体取多少要按自己的评测定——常见起点是各召回 50、融合后取 20、精排后留 5,但这组数字取决于你的语料规模、chunk 粒度、LLM 上下文预算和延迟预算,抄过去只能当第一次实验的初值。纯余弦会漏掉
error code 0x80070057这类精确字符串——语义模型对它没有概念,但倒排索引一击即中。两路的分数不可比,所以融合只看名次不看分数,细节见 混合检索选型 与 BM25 算法原理。3|精排换模型,不换度量。 召回阶段的距离已经用尽了,精排上 Cross-Encoder(如
bge-reranker-v2-m3),把问题和 chunk 拼在一起过一遍模型打分。它能看到两段文本的交互信息,比任何向量距离都准。
顺着图从上往下读:查询分两路召回,融合后进精排。关键是那条红色虚线指向的位置——本篇讨论的所有度量选择,只影响左路最底下那一格。归一化把这一格变成了没有争议的确定项,剩下的质量提升全部来自 RRF 融合和精排模型。
速查表
三种度量对照
| 维度 | 余弦 COSINE | 内积 IP | 欧氏 L2 |
|---|---|---|---|
| 公式 | |||
| 看什么 | 只看方向 | 方向 + 长度 | 绝对位置差 |
| 取值范围 | |||
| 排序方向 | 越大越像 | 越大越像 | 越小越像 |
| 对模长敏感 | 否 | 是 | 是 |
| 数据库返回值 | 相似度原值 | 内积原值 | ⚠️ Milvus/FAISS 返回平方 L2 |
| RAG 里的角色 | 语义召回主力,也最稳妥 | 归一化后与余弦等价 | 归一化后与前两者排序相同,无需刻意选用 |
归一化前后的差别
| 未归一化 | 已 L2 归一化(两侧都要) | |
|---|---|---|
| IP 与 COSINE | 数值不同,排序也不同 | 数值相等,排序相同 |
| L2 与 COSINE | 无固定关系 | ,排序严格互逆 |
| 该选哪个 | 必须选 COSINE | 都行;推荐仍填 COSINE,漏归一化时它能自我兜底 |
「理论排序等价」≠「实测 TopK 一定逐条相同」 上表说的是精确计算下的等价。实际跑 ANN 检索时,以下因素可能让两次结果差个一两条:
- 近似检索本身:HNSW / IVF 都不保证返回精确 TopK,不同度量走的图或聚类划分可能不同;
- 量化:PQ / SQ 会引入误差,不同度量的量化误差分布不一样;
- 浮点误差: 这类变换在接近边界时会放大舍入误差;
- 并列打破规则:分数相同的候选按什么顺序返回,各实现不一致。
所以陷阱 2 的结论要稍作限定:换度量不会带来有意义的效果提升,但如果你看到 TopK 有一两条微小差异,那是正常的数值现象,不用去追。真正的判据是评测指标(Recall / MRR / nDCG)有没有动,而不是结果列表逐条比对。
各家向量库怎么填
| 系统 | 参数写法 | 归一化后的建议 |
|---|---|---|
| Milvus | metric_type="COSINE" / "IP" / "L2" |
填 IP |
| FAISS | IndexFlatIP / IndexFlatL2 |
用 IndexFlatIP |
| Pinecone | metric="cosine" / "dotproduct" / "euclidean" |
dotproduct 或 cosine |
| Weaviate | vectorIndexConfig.distance |
cosine(默认即是) |
| pgvector | 操作符 <=> 余弦 / <#> 负内积 / <-> L2 |
用 <#>,注意它返回负内积 |
复习重点
五条能直接复述的结论
- 余弦看方向,欧氏看位置,内积两个都看。 枢纽等式是 ——内积就是带长度加权的余弦。
- RAG 选余弦,因为 chunk 长度通常是噪音。 同一句话的长版本和短版本应当同样相关,除掉模长的度量才做得到。
- 两侧都归一化之后,三种度量排序一致。 ,所以纠结
metric_type是伪问题,归一化(入库侧 + 查询侧)才是真动作。- 「归一化后改用 IP 更快」是错的。 官方文档没有这个性能承诺,模长在建索引时就算好了,两者查询开销基本相同。归一化的收益是确定性,不是速度。
- 最容易犯的错是拿度量当调优旋钮。 换度量不会带来有意义的效果变化;真正能改变排序的是切块、Embedding 模型、BM25 混合召回和 reranker。
- 换算阈值时记得 Milvus / FAISS 返回的是平方 L2: 对应的库内分数是 0.5,不是 0.707。
一句话收口:先按模型 model card 定归一化与编码协议,两侧都归一化后度量随便选(默认 COSINE),精排交给 reranker。